iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Vibe Coding

Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地系列 第 21

Day 21|Markdown學習筆記系統實作CRUD與富文本即時預覽

  • 分享至 

  • xImage
  •  

在Day20中,我們建置了VibePulse的單一技能詳細頁面/skills/[id],並透過Next.js的ServerComponent(RSC)與ClientComponent混合架構實作了流暢的Tabs切換。

今天(Day21),我們將正式填補詳細頁中的第二個核心頁籤——學習筆記(StudyNotes)模組。

身為開發者或學習者,記錄技術細節、程式碼範例與踩坑經驗是提升技能熟練度的關鍵。今天我們將實作一個支援Markdown語法、程式碼語法高亮(SyntaxHighlighting)、雙欄即時預覽(Split-screenPreview)以及全套CRUDAPI的學習筆記系統!

1.系統架構與資料庫關聯(PrismaModelExtension)
為了支援單一技能擁有多篇筆記,我們首先需要擴充prisma/schema.prisma中的資料模型

2.實戰步驟1:擴充PrismaSchema與Migration
開啟@prisma/schema.prisma,請AI為我們加入Note模型:

// prisma/schema.prisma

model Skill {
  id              String   @id @default(uuid())
  title           String
  category        Category
  proficiency     Int      @default(0)
  tags            String[] @default([])
  status          Status   @default(LEARNING)
  notesCount      Int      @default(0)
  lastPracticedAt DateTime @default(now())
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  // 1:N 關聯
  notes           Note[]

  @@index([category])
  @@index([status])
  @@map("skills")
}

model Note {
  id        String   @id @default(uuid())
  skillId   String
  title     String
  content   String   @db.Text
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 關聯外鍵
  skill     Skill    @relation(fields: [skillId], references: [id], onDelete: Cascade)

  @@index([skillId])
  @@map("notes")
}

在Terminal執行Migration更新資料庫:

npx prisma migrate dev --name add_note_model

3.實戰步驟2:安裝Markdown與語法高亮相關套件
在Terminal執行:

npm install react-markdown remark-gfm rehype-highlight highlight.js

react-markdown:將Markdown字串安全地渲染為HTML節點。

remark-gfm:支援GitHubFlavoredMarkdown(表格、Tasklist、刪除線等)。

rehype-highlight+highlight.js:自動為程式碼區塊(CodeBlocks)進行語法高亮上色。

4.實戰步驟3:撰寫NotesAPIRoutes(app/api/skills/[id]/notes/route.ts)
我們需要建立支援讀取與新增筆記的API端點,並在新增/刪除筆記時自動更新Skill的notesCount數量。

// app/api/skills/[id]/notes/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';

const createNoteSchema = z.object({
  title: z.string().min(1, '請輸入筆記標題').max(100),
  content: z.string().min(1, '筆記內容不能為空'),
});

// GET: 取得特定技能的所有筆記
export async function GET(
  _request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const notes = await prisma.note.findMany({
      where: { skillId: params.id },
      orderBy: { updatedAt: 'desc' },
    });
    return NextResponse.json({ success: true, data: notes });
  } catch (error) {
    return NextResponse.json({ success: false, error: '無法讀取筆記資料' }, { status: 500 });
  }
}

// POST: 新增一篇筆記
export async function POST(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const body = await request.json();
    const validation = createNoteSchema.safeParse(body);

    if (!validation.success) {
      return NextResponse.json({ success: false, error: '資料驗證失敗' }, { status: 400 });
    }

    const { title, content } = validation.data;

    // 使用 Transaction 同時建立 Note 並更新 Skill 的 notesCount
    const [newNote] = await prisma.$transaction([
      prisma.note.create({
        data: {
          skillId: params.id,
          title,
          content,
        },
      }),
      prisma.skill.update({
        where: { id: params.id },
        data: { notesCount: { increment: 1 } },
      }),
    ]);

    return NextResponse.json({ success: true, data: newNote }, { status: 201 });
  } catch (error) {
    return NextResponse.json({ success: false, error: '建立筆記失敗' }, { status: 500 });
  }
}

5.實戰步驟4:建立Markdown編輯與預覽組件(components/notes/NoteEditor.tsx)
這個組件提供編輯(Edit)與即時預覽(Preview)雙模式切換,並引導使用者寫出結構化的學習筆記。

// components/notes/NoteEditor.tsx
'use client';

import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/tokyo-night-dark.css'; // 導入深色程式碼主題
import { Eye, Edit3, Save } from 'lucide-react';

interface NoteEditorProps {
  initialTitle?: string;
  initialContent?: string;
  onSave: (data: { title: string; content: string }) => void;
  isSubmitting?: boolean;
}

export function NoteEditor({
  initialTitle = '',
  initialContent = '',
  onSave,
  isSubmitting = false,
}: NoteEditorProps) {
  const [title, setTitle] = useState(initialTitle);
  const [content, setContent] = useState(initialContent);
  const [mode, setMode] = useState<'write' | 'preview' | 'split'>('split');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!title.trim() || !content.trim()) return;
    onSave({ title, content });
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4 rounded-xl border border-slate-800 bg-slate-900/60 p-6">
      {/* 工具列與模式切換 */}
      <div className="flex flex-wrap items-center justify-between gap-4 pb-4 border-b border-slate-800">
        <input
          type="text"
          placeholder="筆記標題..."
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          className="flex-1 bg-transparent text-lg font-bold text-slate-100 placeholder-slate-500 focus:outline-none"
        />

        <div className="flex items-center gap-2">
          <div className="flex items-center rounded-lg bg-slate-800 p-1 text-xs">
            <button
              type="button"
              onClick={() => setMode('write')}
              className={`px-3 py-1.5 rounded-md transition ${mode === 'write' ? 'bg-cyan-500 text-white' : 'text-slate-400 hover:text-slate-200'}`}
            >
              <Edit3 className="inline h-3.5 w-3.5 mr-1" /> 撰寫
            </button>
            <button
              type="button"
              onClick={() => setMode('preview')}
              className={`px-3 py-1.5 rounded-md transition ${mode === 'preview' ? 'bg-cyan-500 text-white' : 'text-slate-400 hover:text-slate-200'}`}
            >
              <Eye className="inline h-3.5 w-3.5 mr-1" /> 預覽
            </button>
          </div>

          <button
            type="submit"
            disabled={isSubmitting || !title.trim() || !content.trim()}
            className="flex items-center gap-1.5 px-4 py-2 text-sm font-semibold text-white bg-cyan-600 rounded-lg hover:bg-cyan-500 disabled:opacity-50 transition"
          >
            <Save className="h-4 w-4" /> 儲存筆記
          </button>
        </div>
      </div>

      {/* 編輯區與預覽區 */}
      <div className="grid grid-cols-1 md:grid-cols-2 gap-4 min-h-[300px]">
        {(mode === 'write' || mode === 'split') && (
          <textarea
            placeholder="使用 Markdown 記錄你的學習心得與 Code 範例..."
            value={content}
            onChange={(e) => setContent(e.target.value)}
            className="w-full h-full min-h-[300px] p-4 bg-slate-950 border border-slate-800 rounded-lg text-slate-200 text-sm font-mono focus:outline-none focus:border-cyan-500/50 resize-y"
          />
        )}

        {(mode === 'preview' || mode === 'split') && (
          <div className="prose prose-invert max-w-none p-4 bg-slate-950/50 border border-slate-800/80 rounded-lg overflow-y-auto text-sm text-slate-300">
            {content.trim() ? (
              <ReactMarkdown
                remarkPlugins={[remarkGfm]}
                rehypePlugins={[rehypeHighlight]}
              >
                {content}
              </ReactMarkdown>
            ) : (
              <span className="text-slate-600 italic">尚無預覽內容...</span>
            )}
          </div>
        )}
      </div>
    </form>
  );
}

今天我們成功替VibePulse的單一技能頁面升級了完整Markdown筆記功能:

PrismaModel擴充:設計了Skill與Note的1:N關聯,並使用資料庫Transaction自動更新統計計數。

Markdown渲染與語法高亮:整合react-markdown與highlight.js,讓程式碼與筆記呈現專業極致的排版質感。

即時雙欄預覽:提供撰寫/預覽/雙欄split動態切換,大幅提升學習筆記撰寫體驗!


上一篇
Day 20|動態路由與混合渲染實作單一技能詳細頁
下一篇
Day 22|練習日誌與數據視覺化整合Recharts圖表與打卡機制
系列文
Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言